Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Animated Rendering for Progress Bars, Loaders, and More #188

Open
wants to merge 2 commits into
base: 2.x
Choose a base branch
from

Conversation

pdphilip
Copy link

@pdphilip pdphilip commented Sep 5, 2024

This PR introduces animated rendering capabilities to Termwind, allowing for dynamic updates of CLI components like progress bars or any other live-rendered element. I've developed this feature to use in my own package (see: https://github.com/pdphilip/elasticlens) and would love to contribute it back to Termwind.

Why:

Termwind offers excellent flexibility in designing CLI interfaces, but the standard progress bars often look out of place. This PR enhances Termwind's potential by enabling real-time updates to rendered elements, leveraging all its existing design capabilities. This is useful for progress bars, loaders, or any component that requires continuous updates.

Real-world example:

ElasticLens Build

2 New Functions:

1. liveRender(string $html = '', int $options): LiveHtmlRenderer

  • Enables real-time CLI components to re-render by tracking the current terminal position and refreshing the content when reRender() is called
  • Ideal for Progress Bars
$live = liveRender($view); 
// Same as termwind's render() but returns the $live instance
// Methods:
$screenWidth = $live->getScreenWidth(); // Helper method to get terminal width
$live->reRender($view); // Re-renders the $live instance with $view

Example: Progress Bar

$max = 250;
$current = 0;
$live = liveRender(); //Doesnt render at this point, but creates a $live instance at this line
$live->reRender(view('cli.components.progress', [
    'screenWidth' => $live->getScreenWidth(), //The view component needs this
    'current' => 0,
    'max' => $max,
]));
while ($current <= $max) {
    $live->reRender(view('cli.components.progress', [
        'screenWidth' => $live->getScreenWidth(),
        'current' => $current,
        'max' => $max,
    ]));
    $current++;
    usleep(1000);
}

A More Practical Example:

$max = User::count();
$i = 0;
$live = liveRender();
//Start at zero
$live->reRender(view('cli.components.progress', [
    'screenWidth' => $live->getScreenWidth(),
    'current' => $i,
    'max' => $max,
]));
User::chunk(100, function ($users) use ($i, $max, $live) {
    foreach ($users as $user) {
        // Do something to $user
        // Move progress +1
        $i++;
        $live->reRender(view('cli.components.progress', [
            'screenWidth' => $live->getScreenWidth(),
            'current' => $i,
            'max' => $max,
        ]));

    }
});

Demo*

Termwind Progress

  • Blade source for each example is shown in addendum below.

2. asyncFunction(callable $task): AsyncHtmlRenderer

  • Runs an asynchronous task with an animated loader or spinner while the task is processed in the background.
  • Since PHP is synchronous this function creates a fork to run a given task in parallel.
  • If pcntl_fork() is unavailable, it falls back to synchronous behaviour. ie render -> task -> render
  • Ideal for Loaders/Spinners
$async = asyncFunction(callable $task);  
// Initates the Async instance on the given line, 
// And sets the task as a callback

// Methods:
$i = $async->getInterval(); // Returns the current interval
$isRunning = $async->getIsRunning();  // Helper to see if the task is still running
$screenWidth = $async->getScreenWidth(); // Helper method you can use in your view
$async->render($view); // (re)renders a view
$async->withFailOver($view); // Optional view that will be used if pcntl_fork is not available
$result = $async->run(callable $render, int $si = 1000) //Executes the $task and loops the $render in $si micro-sec intervals & returns the $result of the $task once it's done

Note:

  • If nothing is returned in the callable task, then $result will be true
  • If an exception was thrown in the task, then $result will be false

Usage:

//Initate the Async Instance (on this line) and define the task
$async = asyncFunction(function () {
    //Run a task
    sleep(5);
    //Return the result
    return [
        'state' => 'success',
        'message' => 'Completed',
        'details' => 'Index migrated successfully',
    ];
});
//Set a failover view in case the terminal can't fork
$async->withFailOver(view('cli.components.loader', [
    'state' => 'failover',
    'message' => 'Migrating Index',
    'i' => 1,
]));
// Run the task and re-render the view every 0.05s
// Once the task has been completed, it will return $result
$result = $async->run(function () use ($async) {
    $async->render(view('cli.components.loader', [
        'state' => 'running',
        'message' => 'Migrating Index',
        'i' => $async->getInterval(),
    ]));
},50000); //every 0.05 sec
// Use $result to render again
$async->render(view('cli.components.loader', [
    'state' => $result['state'],
    'message' => $result['message'],
    'details' => $result['details'],
    'i' => 0,
]));

Demo

Termwind Loaders


Addendum

Below are the blade files used in the demo examples:

Progress Bar: Example 1
<?php
$length = $screenWidth - (30);
$progress = floor($current / $max * $length);
$remaining = $length - $progress;
$percentage = round(($current / $max) * 100);
$progressColor = "bg-sky-600 ";
$borderColor = "text-sky-500";
if ($max == $current) {
    $borderColor = "text-emerald-300";
    $progressColor = "bg-emerald-600 ";
}
?>
<div class="mx-1">
    <div class="flex w-{{$length + 12}}">
        <span class="w-11"></span>
        <span class="{{$borderColor}}  w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
    </div>
    <div class="flex">
        <span class="w-11 text-right pr-2">{{$current}}/{{$max}}</span>
        <span class="{{$progressColor}} {{$borderColor}} w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="bg-slate-700 text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
        <span class="ml-2">{{$percentage}}%</span>
    </div>
</div>
Progress Bar: Example 2
<?php
$length = $screenWidth - (30);
$progress = floor($current / $max * $length);
$remaining = $length - $progress;
$percentage = round(($current / $max) * 100);
$colors = ['rose', 'red', 'orange', 'amber', 'yellow', 'lime', 'cyan', 'teal', 'emerald', 'green', 'green'];
$i = (int) floor(($current / $max) * 10);
$selectedColor = $colors[$i];

$progressColor = "bg-$selectedColor-600";
$borderColor = "text-$selectedColor-400";
if ($max == $current) {
    $progressColor = "bg-green-600 ";
    $borderColor = "text-green-400";
}
?>
<div class="mx-1">
    <div class="flex w-{{$length + 12}}">
        <span class="w-11"></span>
        <span class="{{$borderColor}}  w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
    </div>
    <div class="flex">
        <span class="w-11 text-right pr-2"><span class="{{$borderColor}}">{{$current}}</span>/{{$max}}</span>
        <span class="{{$progressColor}} {{$borderColor}} w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="bg-slate-700 text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
        <span class="ml-2">{{$percentage}}%</span>
    </div>
</div>
Progress Bar: Example 3
<?php
$length = $screenWidth - (30);
$progress = floor($current / $max * $length);
$remaining = $length - $progress;
$percentage = round(($current / $max) * 100);
$progressColor = "bg-rose-600 text-rose-400";
if ($progress > 25) {
    $progressColor = "bg-sky-600 text-sky-400";
}
if ($max == $current) {
    $progressColor = "bg-emerald-600 text-emerald-300";
}
?>
<div class="mx-1">
    <div class="flex w-{{$length + 12}}">
        <span class="w-10"></span><span>╭</span><span class="flex-1 content-repeat-[─]"></span><span>╮</span>
    </div>
    <div class="flex">
        <span class="w-9 text-right">{{$current}}/{{$max}}</span>
        <span class="w-1"></span>
        <span class="w-1">│</span>
        <span class="{{$progressColor}}  w-{{$progress}} content-repeat-[▁] text-right"></span>
        <span class="bg-slate-700 text-slate-500  w-{{$remaining}} content-repeat-[▁] text-right"></span>
        <span class="w-1">│</span>
        <span class="ml-2">{{$percentage}}%</span>
    </div>
    <div class="flex w-{{$length + 12}}">
        <span class="w-10"></span><span>╰</span><span class="flex-1 content-repeat-[─]"></span><span>╯</span>
    </div>
</div>
Loader: Example 1
<?php
$characters = [
    "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""
];
$intervals = count($characters) - 1;
while ($i > $intervals) {
    $i -= $intervals;
}
$show = $characters[$i];
$textColor = "text-amber-500";
switch ($state) {
    case 'success':
        $textColor = "text-emerald-500";
        $show = "";
        break;
    case 'warning':
        $textColor = "text-amber-500";
        $show = "";
        break;
    case 'failover':
        $textColor = "text-amber-500";
        $show = "";
        break;
    case 'error':
        $textColor = "text-rose-500";
        $show = "";
        break;

}
?>
<div class="m-1 flex">
    <span class="{{$textColor}} mx-1">
        {{ $show }}
    </span>
    <span class="mx-1">{{$message}}</span>
    @if(!empty($details))
        <span class="mx-1 text-slate-600">{{$details}}</span>
    @endif
</div>
Loader: Example 2
<?php
$characters = ["⠉⠉", "⠈⠙", "⠀⠹", "⠀⢸", "⠀⣰", "⢀⣠", "⣀⣀", "⣄⡀", "⣆⠀", "⡇⠀", "⠏⠀", "⠋⠁"];
$colors = ["text-amber-500", "text-emerald-500", "text-rose-500", "text-sky-500"];
$intervals = count($characters) - 1;
$j = 0;
while ($i > $intervals) {
    $i -= $intervals;
    $j++;
    if ($j > 3) {
        $j = 0;
    }
}

$show = $characters[$i];
$textColor = $colors[$j];
switch ($state) {
    case 'success':
        $textColor = "text-emerald-500";
        $show = "";
        break;
    case 'warning':
        $textColor = "text-amber-500";
        $show = "";
        break;
    case 'failover':
        $textColor = "text-amber-500";
        $show = "";
        break;
    case 'error':
        $textColor = "text-rose-500";
        $show = "";
        break;

}
?>
<div class="m-1 flex">
    <span class="{{$textColor}} mx-1">
        {{ $show }}
    </span>
    <span class="mx-1">{{$message}}</span>
    @if(!empty($details))
        <span class="mx-1 text-slate-600">{{$details}}</span>
    @endif
</div>
Loader: Example 3 (This one is insanely complicated, but just showing what’s possible)
<?php
$colors = ['cyan', 'sky', 'blue', 'indigo', 'violet', 'indigo', 'blue', 'sky'];
$stepsPerColor = 20;
$i %= count($colors) * $stepsPerColor;
$colorIndex = floor($i / $stepsPerColor);
$nextColorIndex = ($colorIndex + 1) % count($colors);
$step = $i % $stepsPerColor;

$transitions = [
    [500, 500, 500, 500, 500], [400, 500, 500, 500, 500], [300, 400, 500, 500, 500],
    [300, 300, 400, 500, 500], [400, 300, 300, 400, 500], [500, 400, 300, 300, 400],
    [500, 500, 400, 300, 300], [500, 500, 500, 400, 300], [500, 500, 500, 500, 400],
    [500, 500, 500, 500, 500]
];

$colorTransitions = [
    [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0],
    [1, 0, 0, 0, 0], [1, 1, 0, 0, 0], [1, 1, 1, 0, 0],
    [1, 1, 1, 1, 0], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1],
    [1, 1, 1, 1, 1]
];

$blockWeights = $step < 10 ? $transitions[$step] : [500, 500, 500, 500, 500];
$blockColors = $step < 10 ? array_map(fn($v) => $v ? $nextColorIndex : $colorIndex, $colorTransitions[$step]) : array_fill(0, 5, $nextColorIndex);

$stateConfig = [
    'success' => ['text-emerald-500', ''],
    'warning' => ['text-amber-500', ''],
    'failover' => ['text-amber-500', ''],
    'error' => ['text-rose-500', '']
];

[$textColor, $show] = $stateConfig[$state] ?? [null, false];
?>
<div class="m-1 flex">
    @if($show)
        <div class="{{$textColor}} mx-1">{{$show}}</div>
    @else
        <div class="mx-1 flex">
            @foreach($blockWeights as $index => $weight)
                <span class="w-1 bg-{{$colors[$blockColors[$index]]}}-{{$weight}}"></span>
            @endforeach
        </div>
    @endif
    <span class="mx-1">{{$message}}</span>
    @if(!empty($details))
        <span class="mx-1 text-slate-600">{{$details}}</span>
    @endif
</div>

- Rename $si to $us for microseconds
- asyncFunction() was missing $options parameter on init
@mortenscheel
Copy link

Looks like an awesome feature! I hope it gets merged soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants